今天主要會來處理 Ticket 的部份還有根據權限調整顯示畫面的部份。
今天會先處理權限顯示的邏輯。然後一樣先從存取 api 的邏輯開始實做,最後在把畫面實做出來。
在 Tab 頁面可以透過 showFor 設定該 tab 只顯示給哪些 role 的使用者來看。設定如下
import { TabBarIcon } from '@/components/navigation/TabBarIcon';
import { useAuth } from '@/context/AuthContext';
import { UserRole } from '@/types/user';
import { Ionicons } from '@expo/vector-icons';
import { Tabs } from 'expo-router';
import { ComponentProps } from 'react';
import { Text } from 'react-native';
export default function TabLayout() {
const {user} = useAuth();
const tabs = [
{
showFor: [UserRole.Admin, UserRole.Attendee],
name: '(events)',
displayName: 'Events',
icon: 'calendar',
options: {
headerShown: false
}
},
{
showFor: [UserRole.Attendee],
name: '(tickets)',
displayName: 'My Tickets',
icon: 'ticket',
options: {
headerShown: false
}
},
{
showFor: [UserRole.Admin],
name: 'scan-ticket',
displayName: 'Scan Ticket',
icon: 'scan',
options: {
headerShown: true
}
},
{
showFor: [UserRole.Admin, UserRole.Attendee],
name: 'settings',
displayName: 'Settings',
icon: 'cog',
options: {
headerShown: true
}
},
];
return <Tabs>
{ tabs.map(tab => (
<Tabs.Screen
key={tab.name}
name={tab.name}
options={{
...tab.options,
headerTitle: tab.displayName,
href: tab.showFor.includes(user?.role!)? tab.name: null,
tabBarLabel: ({focused}) => (
<Text style={{color: focused ? 'black': 'gray', fontSize: 12 }}>
{tab.displayName}
</Text>
),
tabBarIcon: ({focused}) => (
<TabBarIcon
name={tab.icon as ComponentProps<typeof Ionicons>['name']}
color={focused? 'black': 'gray'}
/>
)
}}
/>
)) }
</Tabs>;
}
設定顯示 Attendee 才可以看到 My Tickets 頁面。 Admin 才可以看到 Scan Ticket 的頁面。其他就兩個都可以看到。
以 Admin 身分登入
登入畫面顯示
以 Attendee 身份登入
登入畫面顯示
import { ApiResponse } from './api';
import { Event, PageInfo } from './event';
export type TicketResponse = ApiResponse<Ticket>;
export type TicketListResponse = ApiResponse<{tickets: Ticket[], pageInfo: PageInfo}>
export type Ticket = {
id: string;
eventId: string;
event: Event
entered: boolean
createdAt: string
updatedAt: string
}
import { TicketResponse, Ticket, TicketListResponse } from '@/types/ticket';
import { Api } from './api';
import { ApiResponse } from '@/types/api';
async function createOne(eventId: string, userId: string): Promise<TicketResponse> {
return Api.post('/tickets', { eventId, userId, ticketNumber: 1});
}
async function getOne(ticketId: string): Promise<ApiResponse<{ticket: Ticket, qrcode: string}>> {
return Api.get(`/tickets/${ticketId}`);
}
async function getAll(userId: string): Promise<TicketListResponse> {
return Api.get(`/tickets?userId=${userId}`);
}
async function validateOne(ticketId: string, userId: string): Promise<TicketResponse> {
return Api.patch('/tickets', {id: ticketId, userId: userId });
}
const ticketService = {
createOne,
getOne,
getAll,
validateOne
}
export { ticketService }
import { HStack } from '@/components/HStack';
import { Text } from '@/components/Text';
import { VStack } from '@/components/VStack';
import { useAuth } from '@/context/AuthContext';
import { ticketService } from '@/services/ticket';
import { Ticket } from '@/types/ticket';
import { useFocusEffect } from '@react-navigation/native';
import { router, useNavigation } from 'expo-router';
import { useCallback, useEffect, useState } from 'react';
import { TouchableOpacity, FlatList } from 'react-native';
import Toast from 'react-native-root-toast';
export default function TicketScreen() {
const navigation = useNavigation();
const {user} = useAuth();
const [isLoading, setIsLoading] = useState(false);
const [tickets, setTickets] = useState<Ticket[]>([]);
function onGoToTicketPage(id: string) {
router.push(`/(tickets)/ticket/${id}`);
}
async function fetchTickets() {
try {
setIsLoading(true);
const response = await ticketService.getAll(user?.id??'');
setTickets(response.data.tickets);
} catch (error) {
const err: Error = error as Error;
const message = err?.message?? 'unknown server error';
const toast: Toast = Toast.show(message, {
duration: Toast.durations.LONG,
textColor: 'red',
backgroundColor: 'orange'
});
setTimeout(function hideToast() {
Toast.hide(toast);
}, 1500);
} finally {
setIsLoading(false);
}
}
useFocusEffect(useCallback(()=>{fetchTickets()}, []));
useEffect(()=> {
navigation.setOptions({
headerTitle: 'Tickets',
});
}, [navigation])
return (
<VStack flex={1} p={20} pb={0} gap={20}>
<HStack alignItems='center' justifyContent='space-between'>
<Text fontSize={18}>{tickets.length} Tickets</Text>
</HStack>
<FlatList
keyExtractor={({id}) => id}
data={tickets}
onRefresh={fetchTickets}
refreshing={isLoading}
renderItem={({item: ticket}) => (
<TouchableOpacity
disabled={ticket.entered}
onPress={() => onGoToTicketPage(ticket.id)}
>
<VStack
gap={20}
h={120}
key={ticket.id}
style={{ opacity: ticket.entered? 0.5: 1}}
>
<HStack>
<VStack
h={120}
w={'69%'}
p={20}
justifyContent='space-between'
style={{
backgroundColor: 'white',
borderTopLeftRadius: 20,
borderBottomLeftRadius: 20,
borderTopRightRadius: 5,
borderBottomRightRadius: 5
}}
>
<HStack alignItems='center'>
<Text fontSize={22} bold>{ticket.event.name}</Text>
<Text fontSize={22} bold>|</Text>
<Text fontSize={22} bold>{ticket.event.location}</Text>
</HStack>
<Text fontSize={12}>{new Date(ticket.event.startDate).toLocaleString()}</Text>
</VStack>
<VStack
h={110}
w={'1%'}
style={{
alignSelf: 'center',
borderColor: 'lightgray',
borderWidth: 2,
borderStyle: 'dashed'
}}
/>
<VStack
h={120}
w={'29%'}
justifyContent='center'
alignItems='center'
style={{
backgroundColor: 'white',
borderTopRightRadius: 20,
borderBottomRightRadius: 20,
borderTopLeftRadius: 5,
borderBottomLeftRadius: 5,
}}
>
<Text fontSize={16} bold>{ticket.entered ? 'Used': 'Available'}</Text>
{ticket.entered &&
<Text mt={12} fontSize={10}>{new Date(ticket.updatedAt).toLocaleString()}</Text>
}
</VStack>
</HStack>
</VStack>
</TouchableOpacity>
)}
ItemSeparatorComponent={() => <VStack h={20}/>}
/>
</VStack>
);
}
預設會透過 fetchTickets 去讀取當下使用者所購買的 Ticket 清單。並且設定載入讀取單獨一個 Ticket 的邏輯為 onGoToTicketPage 。特別注意的是,這邊一樣透過 useFocusEffect 來處理,當畫面載入時的讀取 fetchTicket 事件,還有使用 useEffect 來綁定 navigation 變動。
async function buyTicket(id: string) {
try {
const response = await ticketService.createOne(id, user?.id??'');
const toast: Toast = Toast.show(response.message, {
duration: Toast.durations.LONG,
});
setTimeout(function hideToast() {
Toast.hide(toast);
}, 1500);
fetchEvents();
} catch(error) {
const err: Error = error as Error;
const message = err?.message?? 'unknown server error';
const toast: Toast = Toast.show(message, {
duration: Toast.durations.LONG,
textColor: 'red',
backgroundColor: 'orange'
});
setTimeout(function hideToast() {
Toast.hide(toast);
}, 1500);
}
}
這個功能是對應 Event 畫面的 Buy Ticket 按鍵。會先建立一張 Ticket 然後更新當下累積的 Event 購買張數。
import { Text } from '@/components/Text';
import { VStack } from '@/components/VStack';
import { ticketService } from '@/services/ticket';
import { Ticket } from '@/types/ticket';
import { useFocusEffect } from '@react-navigation/native';
import { router, useLocalSearchParams, useNavigation } from 'expo-router';
import { useCallback, useEffect, useState } from 'react';
import { Image } from 'react-native';
import Toast from 'react-native-root-toast';
export default function TicketDetailScreen() {
const navigation = useNavigation();
const { id } = useLocalSearchParams();
const [ticket, setTicket] = useState<Ticket|null>(null);
const [qrcode, setQrCode] = useState<string|null>(null);
async function fetchTicket() {
try {
const {data} = await ticketService.getOne(id as string);
setTicket(data.ticket);
setQrCode(data.qrcode);
} catch(error) {
const err: Error = error as Error;
const message = err?.message?? 'unknown server error';
const toast: Toast = Toast.show(message, {
duration: Toast.durations.LONG,
textColor: 'red',
backgroundColor: 'orange'
});
setTimeout(function hideToast() {
Toast.hide(toast);
}, 1500);
router.back();
}
}
useFocusEffect(useCallback(() => { fetchTicket()}, []));
useEffect(()=>{
navigation.setOptions({
headerTitle: ''
});
}, [navigation])
if (!ticket) return null;
return (
<VStack
alignItems='center'
m={20}
p={20}
gap={20}
flex={1}
style={{
backgroundColor: 'white',
borderRadius: 20,
}}
>
<Text fontSize={50} bold>{ticket.event.name}</Text>
<Text fontSize={20} bold>{ticket.event.location}</Text>
<Text fontSize={16} color='gray'>{new Date(ticket.event.startDate).toLocaleString()}</Text>
<Image
style={{
borderRadius: 20
}}
width={300}
height={300}
source={{uri: `data:image/png;base64,${qrcode}`}}
/>
</VStack>
);
}
這邊載入畫面時,會先去透過 fetchTicket 載入單一 Ticket 詳細資料,並且回傳與顯示 qrcode 。而 qrcode 回傳資料主要是使用 base64 的字串,所以可以透過 Image 元件以 data:image/png;base64 格式顯示。
4.1 Buy Ticket
4.2 進入表列查看 買入的 Ticket
4.3 點入顯示 qrcode 頁面
這邊將會使用 expo-camera 元件來作 qrcode 掃描
npx expo install expo-camera
import { Button } from '@/components/Button';
import { Text } from '@/components/Text';
import { VStack } from '@/components/VStack';
import { ticketService } from '@/services/ticket';
import { BarcodeScanningResult, CameraView, useCameraPermissions } from 'expo-camera';
import { router, useNavigation } from 'expo-router';
import { useEffect, useState } from 'react';
import { ActivityIndicator, Alert, Vibration } from 'react-native';
export default function ScanTicketScreen() {
const navigation = useNavigation();
const [permssion, requestPermission] = useCameraPermissions();
const [scanningEnabled, setScanningEnabled] = useState(true);
useEffect(()=>{
navigation.setOptions({
headerTitle: 'Scan Ticket'
})
}, [navigation])
if (!permssion) {
return (
<VStack flex={1} justifyContent='center' alignItems='center'>
<ActivityIndicator size='large'/>
</VStack>
)
}
if (!permssion.granted) {
return (
<VStack gap={20} flex={1} justifyContent='center' alignItems='center'>
<Text>Camera access is required to scan tickets.</Text>
<Button onPress={requestPermission}>Allow Camera Access</Button>
</VStack>
)
}
async function onBarcodeScanned({data}: BarcodeScanningResult) {
if (!scanningEnabled) return;
try {
Vibration.vibrate();
setScanningEnabled(false);
const jsonData = JSON.parse(data);
const [ticketId, userId ] = [jsonData['ticket_id'], jsonData['user_id']];
await ticketService.validateOne(ticketId, userId);
Alert.alert('Success', `Ticket with id ${ticketId} validate successs`, [{
text: 'Scan Next',
onPress: () => {
setScanningEnabled(true);
}
}]);
router.back()
} catch (error) {
Alert.alert('Error', 'Failed to validate ticket please try again', [
{
text: 'Scan Next',
onPress: () => {
setScanningEnabled(true);
}
}
]);
}
}
return (
<CameraView
style={{flex: 1}}
facing='back'
onBarcodeScanned={onBarcodeScanned}
barcodeScannerSettings={{barcodeTypes: ['qr']}}
/>
);
}
這邊關鍵的函數在於 onBarcodeScanned 這個 callback 會把掃描完解析出來的結果放在 Data 的部份。然後在繼續值驗證的數。
這邊由於只有真正的手機才能使用 Camera Scan 功能,所以需要在 Android 手機這邊使用 expo app 載入開發的程式,並且透過 ngrok 本機的 api 放入,讓手機也能夠連通道本機 server 或是使用之前放到 fly.io 上的 api。
到這裡,基本上基礎的訂票活動管理系統就已經完成。
使用 nestjs 製作活動訂票系統,從前面規劃設計,實做到這篇。耗時最多的除了不擅長的 Mobile 元件狀態操控部份,最多就是在設計系統層面。有了適當的設計在後期不論做了那部份的調整都能透過版本控制來作行為差異化的檢查。
本來希望透過一個系統的實作來展現 nestjs 實戰時的作用,但後期由於 mobile app 的互動成份太多。導致整篇文章變成除了介紹了 nestjs ,最多還是 expo 開發。
以下附上我實做系統的部份 github 連結
希望這個系列,能夠幫助到需要使用 nestjs 當作後端 api 開發的人。